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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 52 additions & 4 deletions clipsync/clipboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import io
import logging
import queue
import subprocess
import sys
import threading
Expand Down Expand Up @@ -137,6 +138,8 @@ def __init__(self, settings: config.Settings) -> None:
self._settings = settings
self._stop = threading.Event()
self._poll_thread: threading.Thread | None = None
self._in_thread: threading.Thread | None = None
self._in_queue: queue.SimpleQueue[str] = queue.SimpleQueue()
self._observer: Observer | None = None # type: ignore[valid-type]
self._last_synced: str | bytes | None = None
self._lock = threading.Lock()
Expand All @@ -160,6 +163,8 @@ def start(self) -> None:
self._seed_from_file()
self._poll_thread = threading.Thread(target=self._out_loop, name="clipsync-out", daemon=True)
self._poll_thread.start()
self._in_thread = threading.Thread(target=self._in_loop, name="clipsync-in", daemon=True)
self._in_thread.start()
self._start_watcher()
log.info("Clipboard sync started (host=%s)", _HOSTNAME)

Expand All @@ -172,6 +177,10 @@ def stop(self) -> None:
except Exception:
log.exception("Error stopping file observer")
self._observer = None
# Unblock _in_loop which may be waiting on the queue
self._in_queue.put("")
if self._in_thread and self._in_thread.is_alive():
self._in_thread.join(timeout=3)
if self._poll_thread and self._poll_thread.is_alive():
self._poll_thread.join(timeout=3)
log.info("Clipboard sync stopped")
Expand Down Expand Up @@ -412,6 +421,37 @@ def _out_tick(self) -> None:
except OSError:
log.exception("OUT [%s]: Failed to write clipboard file", _HOSTNAME)

def _in_loop(self) -> None:
"""Drain _in_queue and apply remote file changes to the local clipboard.

Watchdog dispatches events on its own internal thread (backed by a
thread pool on Windows). Doing clipboard I/O there blocks the pool and
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:
try:
path = self._in_queue.get(timeout=0.5)
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")

def _start_watcher(self) -> None:
handler = _ClipboardFileHandler(self)
observer = Observer()
Expand Down Expand Up @@ -480,13 +520,19 @@ def __init__(self, sync: ClipboardSync) -> None:
super().__init__()
self._sync = sync
self._debounce_until = 0.0
# Fast name-based pre-filter to avoid Path.resolve() on every event.
# Syncthing generates many temp-file events; most are irrelevant.
self._target_names = {config.CLIPBOARD_FILENAME, config.CLIPBOARD_IMAGE_FILENAME}
# 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()

def _matches(self, path: str) -> bool:
if Path(path).name not in self._target_names:
return False
try:
resolved = Path(path).resolve()
return (
resolved == self._sync.clipboard_file.resolve() or resolved == self._sync.clipboard_image_file.resolve()
)
return resolved == self._resolved_text or resolved == self._resolved_image
except OSError:
return False

Expand All @@ -497,7 +543,9 @@ def _dispatch(self, path: str) -> None:
if now < self._debounce_until:
return
self._debounce_until = now + 0.1
self._sync._on_file_changed(path)
# 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 on_modified(self, event: FileSystemEvent) -> None:
if event.is_directory:
Expand Down
3 changes: 3 additions & 0 deletions clipsync/pairing.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from collections.abc import Callable

import qrcode
import requests
from PIL import Image

from . import config
Expand Down Expand Up @@ -174,6 +175,8 @@ def _run(self) -> None:
while not self._stop.is_set():
try:
self._tick()
except requests.RequestException as exc:
log.warning("Pending device watcher: Syncthing not responding (%s)", exc)
except Exception:
log.exception("Error in pending device watcher")
if self._stop.wait(self._interval):
Expand Down
13 changes: 10 additions & 3 deletions clipsync/syncthing.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,7 @@ def __init__(self, api_key: str, base_url: str = config.SYNCTHING_API_URL) -> No
self._base = base_url.rstrip("/")
self._session = requests.Session()
self._session.headers["X-API-Key"] = api_key
self._cached_device_id: str | None = None

def _url(self, path: str) -> str:
return f"{self._base}{path}"
Expand Down Expand Up @@ -557,8 +558,10 @@ def wait_until_ready(self, timeout: float = _STARTUP_WAIT) -> bool:
return False

def get_device_id(self) -> str:
status = self._get("/rest/system/status")
return status["myID"]
if self._cached_device_id is None:
status = self._get("/rest/system/status")
self._cached_device_id = status["myID"]
return self._cached_device_id

def get_config(self) -> dict[str, Any]:
return self._get("/rest/config")
Expand Down Expand Up @@ -646,7 +649,11 @@ def connected_devices(self) -> list[dict[str, Any]]:
"""Return a list of {deviceID, name, connected, address} for paired devices."""
devices = self.get_devices()
my_id = self.get_device_id()
connections = self.get_connections().get("connections") or {}
try:
connections = self.get_connections().get("connections") or {}
except requests.RequestException:
# Connection status is best-effort; don't fail the whole call.
connections = {}
out: list[dict[str, Any]] = []
for d in devices:
did = d.get("deviceID")
Expand Down
61 changes: 55 additions & 6 deletions clipsync/ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from pathlib import Path

import customtkinter as ctk
import requests
from PIL import Image

from . import __version__, config, pairing, update
Expand Down Expand Up @@ -551,6 +552,7 @@ def __init__(
) -> None:
self._win = window
self._app = app
self._refreshing = False

ctk.CTkLabel(container, text="Connected devices", font=ctk.CTkFont(size=18, weight="bold")).pack(pady=(0, 10))

Expand All @@ -577,7 +579,7 @@ def _exists(self) -> bool:
def _schedule_refresh(self) -> None:
if not self._exists():
return
self._win.after(3000, self._auto_refresh)
self._win.after(10_000, self._auto_refresh)

def _auto_refresh(self) -> None:
if not self._exists():
Expand All @@ -586,12 +588,35 @@ def _auto_refresh(self) -> None:
self._schedule_refresh()

def _refresh(self) -> None:
if self._refreshing:
return
self._refreshing = True
for child in self._list_frame.winfo_children():
child.destroy()
ctk.CTkLabel(self._list_frame, text="Loading…", text_color=("gray50", "gray60")).pack(pady=20)
threading.Thread(target=self._do_refresh, daemon=True).start()

def _do_refresh(self) -> None:
try:
devices = self._app.client.connected_devices()
devices: list[dict] = self._app.client.connected_devices()
error: str | None = None
except requests.RequestException:
devices = []
error = "Syncthing is not responding"
except Exception as exc:
ctk.CTkLabel(self._list_frame, text=f"Error: {exc}", text_color="red").pack(pady=10)
devices = []
error = str(exc)
if self._exists():
self._win.after(0, self._apply_refresh, devices, error)
else:
self._refreshing = False

def _apply_refresh(self, devices: list[dict], error: str | None) -> None:
self._refreshing = False
for child in self._list_frame.winfo_children():
child.destroy()
if error:
ctk.CTkLabel(self._list_frame, text=error, text_color="red").pack(pady=10)
return
if not devices:
empty = ctk.CTkFrame(self._list_frame, fg_color="transparent")
Expand Down Expand Up @@ -1177,6 +1202,7 @@ def __init__(self, parent: ctk.CTk, app: AppContext, on_close: Callable[[], None
super().__init__(parent, f"{config.APP_NAME} — Incoming Requests", (440, 360), on_close)
self._app = app
self._handled: set[str] = set()
self._refreshing = False

container = ctk.CTkFrame(self.window, fg_color="transparent")
container.pack(fill="both", expand=True, padx=20, pady=20)
Expand All @@ -1203,7 +1229,7 @@ def __init__(self, parent: ctk.CTk, app: AppContext, on_close: Callable[[], None
def _schedule_refresh(self) -> None:
if not self.exists():
return
self.window.after(3000, self._auto_refresh)
self.window.after(10_000, self._auto_refresh)

def _auto_refresh(self) -> None:
if not self.exists():
Expand All @@ -1212,12 +1238,35 @@ def _auto_refresh(self) -> None:
self._schedule_refresh()

def _refresh(self) -> None:
if self._refreshing:
return
self._refreshing = True
for child in self._list_frame.winfo_children():
child.destroy()
ctk.CTkLabel(self._list_frame, text="Loading…", text_color=("gray50", "gray60")).pack(pady=20)
threading.Thread(target=self._do_refresh, daemon=True).start()

def _do_refresh(self) -> None:
try:
pending = self._app.client.get_pending_devices() or {}
pending: dict = self._app.client.get_pending_devices() or {}
error: str | None = None
except requests.RequestException:
pending = {}
error = "Syncthing is not responding"
except Exception as exc:
ctk.CTkLabel(self._list_frame, text=f"Error: {exc}", text_color="red").pack(pady=10)
pending = {}
error = str(exc)
if self.exists():
self.window.after(0, self._apply_refresh, pending, error)
else:
self._refreshing = False

def _apply_refresh(self, pending: dict, error: str | None) -> None:
self._refreshing = False
for child in self._list_frame.winfo_children():
child.destroy()
if error:
ctk.CTkLabel(self._list_frame, text=error, text_color="red").pack(pady=10)
return
rejected = set(self._app.settings.get("rejected_device_ids") or [])
visible = [
Expand Down