From 3c90faed7bff97897bfd8d640af74fcfae4355ee Mon Sep 17 00:00:00 2001 From: offbyonebit <83889256+offbyonebit@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:17:03 -0500 Subject: [PATCH 1/4] feat: harden secrets, transfers, and recovery --- .github/workflows/ci.yml | 7 +- clipsync/build.py | 5 ++ clipsync/config.py | 95 +++++++++++++++++++++++++- clipsync/file_transfer.py | 55 ++++++++++++++- clipsync/syncthing.py | 17 +++-- clipsync/ui.py | 61 ++++++++++++++++- pyproject.toml | 7 ++ requirements.txt | 1 + tests/conftest.py | 18 +++++ tests/test_build.py | 9 +++ tests/test_file_transfer_encryption.py | 45 +++++++++++- tests/test_secret_storage.py | 42 ++++++++++++ tests/test_syncthing_restart.py | 56 +++++++++++++++ 13 files changed, 405 insertions(+), 13 deletions(-) create mode 100644 tests/test_build.py create mode 100644 tests/test_secret_storage.py create mode 100644 tests/test_syncthing_restart.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9f7882d..7b17589 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,12 @@ on: jobs: lint-and-test: - runs-on: ubuntu-latest + name: ${{ matrix.os }} / Python 3.12 + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@v4 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/config.py b/clipsync/config.py index 36fe2d7..7069db2 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,7 +111,6 @@ 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, @@ -127,6 +127,20 @@ def assets_dir() -> Path: } HISTORY_FILE = APP_DATA_DIR / "clipsync_history.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 +155,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,11 +179,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 + self._migrate_legacy_passphrase_locked() # Only persist if the on-disk file is incomplete (missing a default # key) or has an empty api_key that we just generated. Otherwise # leave the file alone: rewriting it on every startup is needless @@ -188,8 +206,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: @@ -216,13 +241,76 @@ def _refresh_if_changed(self) -> None: def get(self, key: str, default: Any = None) -> Any: with self._lock: self._refresh_if_changed() + if key == "encryption_passphrase": + 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": + self.set_passphrase(value) + return self._data[key] = value self._persist_locked() + def _get_passphrase_locked(self) -> str: + 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) + return "" + if isinstance(value, str): + return value + return self._migrate_legacy_passphrase_locked() + + 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 + self._legacy_passphrase = None + self._persist_locked() + def update(self, **kwargs: Any) -> None: with self._lock: self._data.update(kwargs) @@ -248,9 +336,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 fb28661..756910b 100644 --- a/clipsync/file_transfer.py +++ b/clipsync/file_transfer.py @@ -17,6 +17,7 @@ import shutil import threading import time +from collections import deque from collections.abc import Callable from pathlib import Path @@ -34,6 +35,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: @@ -57,6 +60,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. @@ -72,7 +84,7 @@ def send(self, source: Path) -> Path: 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 + size = self._validate_outgoing_source(source) if passphrase: dest = dest_dir / f"{timestamp}_{source.name}{ENCRYPTED_SUFFIX}" @@ -97,7 +109,7 @@ def send(self, source: Path) -> Path: def start(self) -> None: self.files_dir.mkdir(parents=True, exist_ok=True) - handler = _FileReceiveHandler(on_received=self._on_received) + handler = _FileReceiveHandler(on_received=self._on_received, files_dir=self.files_dir) observer = Observer() observer.schedule(handler, str(self.files_dir), recursive=True) observer.start() @@ -114,7 +126,7 @@ def stop(self) -> None: 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], None], files_dir: Path | None = None) -> None: super().__init__() self._on_received = on_received # Guard against duplicate events (watchdog can fire multiple times for @@ -124,20 +136,57 @@ def __init__(self, on_received: Callable[[Path, str], None]) -> None: # file can both pass it and deliver the file twice. self._seen: set[str] = set() self._seen_lock = threading.Lock() + self._files_dir = files_dir.resolve() if files_dir is not None else None + self._received_at: deque[float] = deque() + + 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 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._seen: return + if not self._within_rate_limit(): + log.warning("Ignoring received file; rate limit reached") + return self._seen.add(key) log.info("FILE IN [%s]: %s from %s", _HOSTNAME, path.name, sender) try: diff --git a/clipsync/syncthing.py b/clipsync/syncthing.py index 06c8aaf..8a41a4d 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() @@ -1047,6 +1053,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 @@ -1058,15 +1065,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 7de5018..7b0c310 100644 --- a/clipsync/ui.py +++ b/clipsync/ui.py @@ -974,6 +974,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)) @@ -1006,7 +1014,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") @@ -1154,6 +1162,9 @@ def _on_log_mirror_toggle(self) -> None: 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( @@ -1164,6 +1175,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() @@ -1180,7 +1233,11 @@ def _on_auto_clear_changed(self, value: str) -> None: 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.") diff --git a/pyproject.toml b/pyproject.toml index d764f32..1754572 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "requests>=2.31.0", "opencv-python>=4.9.0.80", "cryptography>=41.0.0", + "keyring>=25.0.0", "pyobjc-core>=10.0; sys_platform == 'darwin'", "pyobjc-framework-Cocoa>=10.0; sys_platform == 'darwin'", ] @@ -75,3 +76,9 @@ warn_unused_ignores = true 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 e0ed204..4d56437 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,5 +10,6 @@ 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.0 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..8b10425 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,6 +26,22 @@ from clipsync import config +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) def _isolate_user_data(tmp_path, monkeypatch): """Point every user-data path at a per-test temp directory. @@ -46,4 +62,6 @@ def _isolate_user_data(tmp_path, monkeypatch): 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) return data_dir 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_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_secret_storage.py b/tests/test_secret_storage.py new file mode 100644 index 0000000..dad2dda --- /dev/null +++ b/tests/test_secret_storage.py @@ -0,0 +1,42 @@ +"""Encryption passphrases belong in the OS keychain, never settings.json.""" + +from __future__ import annotations + +import json + +from clipsync import config + + +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" 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] From 20d263b0fd6935584be58aeaadca74b3ea499108 Mon Sep 17 00:00:00 2001 From: offbyonebit <83889256+offbyonebit@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:20:11 -0500 Subject: [PATCH 2/4] fix: make clipboard delivery retry-safe --- clipsync/clipboard.py | 222 +++++++++++++++++++++------- clipsync/main.py | 2 + tests/test_clipboard_reliability.py | 141 ++++++++++++++++++ 3 files changed, 311 insertions(+), 54 deletions(-) create mode 100644 tests/test_clipboard_reliability.py diff --git a/clipsync/clipboard.py b/clipsync/clipboard.py index 400f06e..ad0c5b3 100644 --- a/clipsync/clipboard.py +++ b/clipsync/clipboard.py @@ -32,6 +32,7 @@ import sys import threading import time +from dataclasses import dataclass from pathlib import Path import pyperclip @@ -73,6 +74,17 @@ 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) + + +@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. @@ -483,6 +495,12 @@ def __init__(self, settings: config.Settings) -> 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 @property def clipboard_file(self) -> Path: @@ -753,22 +771,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: @@ -862,20 +879,33 @@ def _out_loop(self) -> None: ) def _out_tick(self) -> None: + 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: 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: self._write_image_file(image) log.info("OUT [%s]: %d bytes image written", _HOSTNAME, len(image)) except EncryptedPayloadError: with self._lock: - self._last_synced = previous_last_synced + 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) @@ -885,7 +915,8 @@ def _out_tick(self) -> None: # 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._last_synced = previous_last_synced + 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 @@ -893,9 +924,18 @@ def _out_tick(self) -> None: if current is None or current == "": 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: self._write_file(current) @@ -903,14 +943,16 @@ def _out_tick(self) -> None: self._history.add_entry(current, "local") except EncryptedPayloadError: with self._lock: - self._last_synced = previous_last_synced + 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 except OSError: with self._lock: - self._last_synced = previous_last_synced + 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) def _in_loop(self) -> None: @@ -921,28 +963,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) @@ -969,29 +1011,83 @@ 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") + 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 value == self._last_synced: + log.debug("IN [%s]: %s file changed but content is already synced", _HOSTNAME, kind) + return + 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: - if image == self._last_synced: - log.debug("IN [%s]: image file changed but already synced (%d bytes)", _HOSTNAME, len(image)) + 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 - self._last_synced = image - if self._write_clipboard_image(image): - log.info("IN [%s]: %d bytes image applied to clipboard", _HOSTNAME, len(image)) + pending.attempts += 1 + if ok: + self._generation += 1 + self._last_synced = pending.value + self._last_observed_clipboard = pending.value + self._pending_incoming = None + committed = pending + elif pending.attempts <= len(_INCOMING_RETRY_DELAYS): + pending.next_attempt = now + _INCOMING_RETRY_DELAYS[pending.attempts - 1] + return + else: + self._pending_incoming = None + log.warning( + "IN [%s]: %s clipboard write failed after %d attempts", _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): + 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() class _ClipboardFileHandler(FileSystemEventHandler): @@ -1010,6 +1106,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: @@ -1025,13 +1124,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 on_modified(self, event: FileSystemEvent) -> None: if event.is_directory: return diff --git a/clipsync/main.py b/clipsync/main.py index 62c52b6..75c0b2c 100644 --- a/clipsync/main.py +++ b/clipsync/main.py @@ -412,6 +412,8 @@ 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: + self.clipboard.reconcile_latest() def _on_device_accepted(self, device_id: str) -> None: self._notify("Device connected", f"Now syncing clipboard with {device_id[:7]}") diff --git a/tests/test_clipboard_reliability.py b/tests/test_clipboard_reliability.py new file mode 100644 index 0000000..ed3b8c0 --- /dev/null +++ b/tests/test_clipboard_reliability.py @@ -0,0 +1,141 @@ +"""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_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 == [] From 525113f7c1e94b99cb617b733747a4b52aa9519a Mon Sep 17 00:00:00 2001 From: offbyonebit <83889256+offbyonebit@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:01:02 -0500 Subject: [PATCH 3/4] feat: add sync controls, history tools, and transfer acknowledgements --- README.md | 22 ++- clipsync/clipboard.py | 177 ++++++++++++++++- clipsync/config.py | 8 + clipsync/file_transfer.py | 292 +++++++++++++++++++++++++++-- clipsync/history.py | 113 ++++++++++- clipsync/main.py | 166 ++++++++++++++-- clipsync/syncthing.py | 13 ++ clipsync/ui.py | 244 ++++++++++++++++++++---- pyproject.toml | 1 + requirements.txt | 1 + tests/conftest.py | 1 + tests/test_audit_fixes.py | 2 +- tests/test_product_improvements.py | 225 ++++++++++++++++++++++ uv.lock | 193 +++++++++++++++++++ 14 files changed, 1380 insertions(+), 78 deletions(-) create mode 100644 tests/test_product_improvements.py diff --git a/README.md b/README.md index be24ec5..0fb30ea 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,12 @@ 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. + 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/clipboard.py b/clipsync/clipboard.py index ad0c5b3..03a2001 100644 --- a/clipsync/clipboard.py +++ b/clipsync/clipboard.py @@ -379,6 +379,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"): @@ -490,6 +503,7 @@ 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 @@ -501,6 +515,10 @@ def __init__(self, settings: config.Settings) -> None: 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: @@ -514,6 +532,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") @@ -539,6 +558,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() @@ -569,6 +589,66 @@ 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 + ) + 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, + "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" + 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 = "" + if isinstance(outbound, (int, float)) and (not isinstance(inbound, (int, float)) or outbound >= inbound): + activity = f" · published {_relative_age(outbound)}" + elif isinstance(inbound, (int, float)): + activity = f" · applied {_relative_age(inbound)}" + return f"Status: {status['replication']}{activity}{suffix}" + def _passphrase(self) -> str: val = self._settings.get("encryption_passphrase") or "" return val if isinstance(val, str) else "" @@ -581,6 +661,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) @@ -604,11 +688,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") @@ -657,6 +749,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) @@ -683,6 +779,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: @@ -751,7 +851,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: @@ -810,14 +927,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: @@ -827,6 +951,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: @@ -878,13 +1003,18 @@ 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 @@ -900,21 +1030,32 @@ def _out_tick(self) -> 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 - 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) @@ -923,6 +1064,9 @@ def _out_tick(self) -> None: current = self._read_clipboard() if current is None or current == "": 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 @@ -938,19 +1082,29 @@ def _out_tick(self) -> 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 - 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) @@ -1056,6 +1210,8 @@ def _attempt_pending_incoming(self) -> None: 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 elif pending.attempts <= len(_INCOMING_RETRY_DELAYS): @@ -1063,6 +1219,7 @@ def _attempt_pending_incoming(self) -> None: return else: self._pending_incoming = None + self._last_write_error = f"Could not apply incoming {pending.kind} to the system clipboard" log.warning( "IN [%s]: %s clipboard write failed after %d attempts", _HOSTNAME, pending.kind, pending.attempts ) @@ -1071,6 +1228,7 @@ def _attempt_pending_incoming(self) -> None: 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: @@ -1089,6 +1247,13 @@ def reconcile_latest(self) -> None: 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): """Dispatch watchdog events for the clipboard files to ClipboardSync.""" diff --git a/clipsync/config.py b/clipsync/config.py index 7069db2..ff6f062 100644 --- a/clipsync/config.py +++ b/clipsync/config.py @@ -116,6 +116,13 @@ def assets_dir() -> Path: "history_enabled": True, "history_max_items": 50, "history_auto_clear_minutes": 0, + "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 +134,7 @@ 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" diff --git a/clipsync/file_transfer.py b/clipsync/file_transfer.py index 756910b..372969e 100644 --- a/clipsync/file_transfer.py +++ b/clipsync/file_transfer.py @@ -12,11 +12,14 @@ from __future__ import annotations +import json import logging import os +import queue import shutil import threading import time +import uuid from collections import deque from collections.abc import Callable from pathlib import Path @@ -45,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: @@ -80,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 = self._validate_outgoing_source(source) 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, files_dir=self.files_dir) + 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], files_dir: Path | None = 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 @@ -138,6 +377,8 @@ def __init__(self, on_received: Callable[[Path, str], None], files_dir: Path | N self._seen_lock = threading.Lock() 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() @@ -161,6 +402,8 @@ def _handle(self, path: Path) -> None: 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 @@ -185,7 +428,14 @@ def _handle(self, path: Path) -> None: if key in self._seen: return if not self._within_rate_limit(): - log.warning("Ignoring received file; rate limit reached") + self._seen.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.add(key) log.info("FILE IN [%s]: %s from %s", _HOSTNAME, path.name, sender) @@ -194,6 +444,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._seen.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 on_created(self, event: FileSystemEvent) -> None: if not event.is_directory: self._handle(Path(os.fsdecode(event.src_path))) 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 75c0b2c..3c5af28 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,9 @@ 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("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 +243,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 +267,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 +285,82 @@ 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 _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 +472,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": @@ -413,10 +513,13 @@ def _on_pause_changed(self, paused: bool) -> None: "Clipboard sync is off." if paused else "Clipboard sync is on.", ) if not paused and self.clipboard is not None: - self.clipboard.reconcile_latest() + 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. @@ -453,7 +556,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) @@ -461,13 +569,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) @@ -478,6 +601,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 = "" @@ -487,7 +613,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 @@ -507,7 +633,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 @@ -523,7 +649,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: @@ -535,13 +661,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 --------------------------------------------------------------- @@ -556,6 +688,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 8a41a4d..b5a658f 100644 --- a/clipsync/syncthing.py +++ b/clipsync/syncthing.py @@ -864,6 +864,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() @@ -932,12 +938,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 diff --git a/clipsync/ui.py b/clipsync/ui.py index 7b0c310..9372ca6 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( @@ -593,12 +604,15 @@ def _render_nearby(self, device_ids: list[str]) -> None: ).pack(pady=(4, 0)) return for did in device_ids: - row = ctk.CTkFrame(self._nearby_frame, fg_color=(config.COLOR_ROW_BG_LIGHT, config.COLOR_ROW_BG_DARK), corner_radius=8) + row = ctk.CTkFrame( + self._nearby_frame, fg_color=(config.COLOR_ROW_BG_LIGHT, config.COLOR_ROW_BG_DARK), corner_radius=8 + ) row.pack(fill="x", padx=8, pady=4) row.grid_columnconfigure(0, weight=1) ctk.CTkLabel(row, text=did[:24] + "…", font=_fonts()["small"], anchor="w", text_color=THEME.text).grid( row=0, column=0, sticky="we", padx=12, pady=10 ) + def _pair_handler(d: str = did) -> None: self._pair_from_nearby(d) @@ -808,7 +822,9 @@ def _apply_refresh(self, devices: list[dict], error: str | None) -> None: for child in self._list_frame.winfo_children(): child.destroy() if error: - ctk.CTkLabel(self._list_frame, text=error, text_color=config.COLOR_DANGER, font=_fonts()["body"]).pack(pady=14) + ctk.CTkLabel(self._list_frame, text=error, text_color=config.COLOR_DANGER, font=_fonts()["body"]).pack( + pady=14 + ) return if not devices: empty = ctk.CTkFrame(self._list_frame, fg_color="transparent") @@ -830,7 +846,9 @@ def _apply_refresh(self, devices: list[dict], error: str | None) -> None: self._build_row(d) def _build_row(self, device: dict) -> None: - row = ctk.CTkFrame(self._list_frame, fg_color=(config.COLOR_ROW_BG_LIGHT, config.COLOR_ROW_BG_DARK), corner_radius=10) + row = ctk.CTkFrame( + self._list_frame, fg_color=(config.COLOR_ROW_BG_LIGHT, config.COLOR_ROW_BG_DARK), corner_radius=10 + ) row.pack(fill="x", padx=8, pady=5) row.grid_columnconfigure(0, weight=1) @@ -838,12 +856,16 @@ def _build_row(self, device: dict) -> None: ctk.CTkLabel(row, text=name_text, font=_fonts()["subtitle"], text_color=THEME.text, anchor="w").grid( row=0, column=0, sticky="we", padx=12, pady=(10, 0) ) - ctk.CTkLabel(row, text=device["deviceID"][:24] + "…", font=_fonts()["tiny"], text_color=THEME.muted, anchor="w").grid( - row=1, column=0, sticky="we", padx=12, pady=(0, 10) - ) + ctk.CTkLabel( + row, text=device["deviceID"][:24] + "…", font=_fonts()["tiny"], text_color=THEME.muted, anchor="w" + ).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 ) @@ -967,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, @@ -1002,9 +1032,7 @@ def __init__( 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) - ) + _section_header(privacy_card, "Encryption passphrase (optional)").pack(anchor="w", padx=16, pady=(4, 2)) ctk.CTkLabel( privacy_card, text="Same passphrase on every device. Empty = no encryption.", @@ -1074,6 +1102,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)) @@ -1148,6 +1222,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) @@ -1231,6 +1311,16 @@ 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() try: @@ -1462,7 +1552,9 @@ def __init__(self, parent: ctk.CTk, on_close: Callable[[], None]) -> None: super().__init__(parent, f"{config.APP_NAME} — Logs", (640, 440), on_close) container = ctk.CTkFrame(self.window, fg_color="transparent") container.pack(fill="both", expand=True, padx=20, pady=20) - ctk.CTkLabel(container, text="Logs", font=_fonts()["headline"], text_color=THEME.text).pack(anchor="w", pady=(0, 12)) + ctk.CTkLabel(container, text="Logs", font=_fonts()["headline"], text_color=THEME.text).pack( + anchor="w", pady=(0, 12) + ) self._textbox = ctk.CTkTextbox( container, wrap="none", @@ -1506,9 +1598,9 @@ def __init__(self, parent: ctk.CTk, app: AppContext, on_close: Callable[[], None container = ctk.CTkFrame(self.window, fg_color="transparent") container.pack(fill="both", expand=True, padx=20, pady=20) - ctk.CTkLabel( - container, text="Incoming device requests", font=_fonts()["headline"], text_color=THEME.text - ).pack(pady=(0, 6)) + ctk.CTkLabel(container, text="Incoming device requests", font=_fonts()["headline"], text_color=THEME.text).pack( + pady=(0, 6) + ) ctk.CTkLabel( container, text="Accept a device to start syncing clipboard with it.", @@ -1571,7 +1663,9 @@ def _apply_refresh(self, pending: dict, error: str | None) -> None: for child in self._list_frame.winfo_children(): child.destroy() if error: - ctk.CTkLabel(self._list_frame, text=error, text_color=config.COLOR_DANGER, font=_fonts()["body"]).pack(pady=14) + ctk.CTkLabel(self._list_frame, text=error, text_color=config.COLOR_DANGER, font=_fonts()["body"]).pack( + pady=14 + ) return rejected = set(self._app.settings.get("rejected_device_ids") or []) visible = [ @@ -1599,7 +1693,9 @@ def _apply_refresh(self, pending: dict, error: str | None) -> None: self._build_row(device_id, info) def _build_row(self, device_id: str, info: dict) -> None: - row = ctk.CTkFrame(self._list_frame, fg_color=(config.COLOR_ROW_BG_LIGHT, config.COLOR_ROW_BG_DARK), corner_radius=10) + row = ctk.CTkFrame( + self._list_frame, fg_color=(config.COLOR_ROW_BG_LIGHT, config.COLOR_ROW_BG_DARK), corner_radius=10 + ) row.pack(fill="x", padx=8, pady=5) row.grid_columnconfigure(0, weight=1) @@ -1660,7 +1756,9 @@ def __init__(self, parent: ctk.CTk, app: AppContext, on_close: Callable[[], None header = ctk.CTkFrame(container, fg_color="transparent") header.pack(fill="x", pady=(0, 10)) - ctk.CTkLabel(header, text="Clipboard History", font=_fonts()["headline"], text_color=THEME.text).pack(side="left") + ctk.CTkLabel(header, text="Clipboard History", font=_fonts()["headline"], text_color=THEME.text).pack( + side="left" + ) self._status = ctk.CTkLabel(header, text="", font=_fonts()["small"], text_color=THEME.muted) self._status.pack(side="right") @@ -1687,26 +1785,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") @@ -1746,14 +1863,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 @@ -1777,16 +1897,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", @@ -1794,8 +1924,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, @@ -1805,11 +1935,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: @@ -1846,9 +2016,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 1754572..e8ec163 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "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'", ] diff --git a/requirements.txt b/requirements.txt index 4d56437..87e9ae3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,5 +11,6 @@ opencv-python>=4.9.0.80 numpy<2.5 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" diff --git a/tests/conftest.py b/tests/conftest.py index 8b10425..8c69b4a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,6 +56,7 @@ 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) diff --git a/tests/test_audit_fixes.py b/tests/test_audit_fixes.py index 36a7e57..abaa03b 100644 --- a/tests/test_audit_fixes.py +++ b/tests/test_audit_fixes.py @@ -313,7 +313,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)) diff --git a/tests/test_product_improvements.py b/tests/test_product_improvements.py new file mode 100644 index 0000000..7cca536 --- /dev/null +++ b/tests/test_product_improvements.py @@ -0,0 +1,225 @@ +"""Regression coverage for privacy, history, status, and transfer lifecycle.""" + +from __future__ import annotations + +import io +import time +from pathlib import Path + +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" + + +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/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" }, +] From 71f21d487a935cc79010981d1d427653ce8125c4 Mon Sep 17 00:00:00 2001 From: offbyonebit <83889256+offbyonebit@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:01:21 -0500 Subject: [PATCH 4/4] Improve clipboard retry recovery and privacy controls --- README.md | 12 ++++ clipsync/clipboard.py | 93 +++++++++++++++++++++++++---- clipsync/config.py | 1 + clipsync/main.py | 11 ++++ clipsync/ui.py | 53 +++++++++++++++- tests/test_clipboard_reliability.py | 36 +++++++++++ tests/test_product_improvements.py | 50 ++++++++++++++++ 7 files changed, 242 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 0fb30ea..82398fe 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,18 @@ 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/clipboard.py b/clipsync/clipboard.py index 03a2001..67ec2c8 100644 --- a/clipsync/clipboard.py +++ b/clipsync/clipboard.py @@ -28,6 +28,7 @@ import logging import os import queue +import re import subprocess import sys import threading @@ -75,6 +76,26 @@ class EncryptedPayloadError(RuntimeError): _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 @@ -611,6 +632,7 @@ def status_snapshot(self) -> dict[str, object]: 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), @@ -618,6 +640,14 @@ def status_snapshot(self) -> dict[str, object]: "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, } @@ -635,6 +665,18 @@ def status_text(self) -> str: 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"]: @@ -642,13 +684,26 @@ def status_text(self) -> str: suffix = " · manual send" if status["manual_send"] else "" outbound = status["last_outbound_at"] inbound = status["last_inbound_at"] - activity = "" - if isinstance(outbound, (int, float)) and (not isinstance(inbound, (int, float)) or outbound >= inbound): - activity = f" · published {_relative_age(outbound)}" - elif isinstance(inbound, (int, float)): - activity = f" · applied {_relative_age(inbound)}" + 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 "" @@ -1064,6 +1119,9 @@ def _out_tick(self, force: bool = False) -> None: 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 @@ -1165,6 +1223,12 @@ def _on_text_file_changed(self) -> None: content = self._read_file() if content is None: return + 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: @@ -1214,15 +1278,18 @@ def _attempt_pending_incoming(self) -> None: self._last_write_error = None self._pending_incoming = None committed = pending - elif pending.attempts <= len(_INCOMING_RETRY_DELAYS): - pending.next_attempt = now + _INCOMING_RETRY_DELAYS[pending.attempts - 1] - return else: - self._pending_incoming = None - self._last_write_error = f"Could not apply incoming {pending.kind} to the system clipboard" - log.warning( - "IN [%s]: %s clipboard write failed after %d attempts", _HOSTNAME, pending.kind, pending.attempts - ) + 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") diff --git a/clipsync/config.py b/clipsync/config.py index ff6f062..85bd99f 100644 --- a/clipsync/config.py +++ b/clipsync/config.py @@ -116,6 +116,7 @@ def assets_dir() -> Path: "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, diff --git a/clipsync/main.py b/clipsync/main.py index 3c5af28..2d69ecd 100644 --- a/clipsync/main.py +++ b/clipsync/main.py @@ -233,6 +233,13 @@ 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), @@ -348,6 +355,10 @@ def _menu_send_clipboard(self, _icon: pystray.Icon, _item: pystray.MenuItem) -> 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: diff --git a/clipsync/ui.py b/clipsync/ui.py index 9372ca6..61bf4e7 100644 --- a/clipsync/ui.py +++ b/clipsync/ui.py @@ -1032,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, @@ -1072,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, @@ -1240,6 +1275,22 @@ 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: diff --git a/tests/test_clipboard_reliability.py b/tests/test_clipboard_reliability.py index ed3b8c0..077b01c 100644 --- a/tests/test_clipboard_reliability.py +++ b/tests/test_clipboard_reliability.py @@ -38,6 +38,42 @@ def test_failed_incoming_write_retries_and_commits_only_on_success(tmp_path, mon 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" diff --git a/tests/test_product_improvements.py b/tests/test_product_improvements.py index 7cca536..e196687 100644 --- a/tests/test_product_improvements.py +++ b/tests/test_product_improvements.py @@ -6,6 +6,7 @@ import time from pathlib import Path +import pytest from PIL import Image from clipsync import config @@ -78,6 +79,55 @@ def test_manual_send_requires_explicit_send(tmp_path, monkeypatch): 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)