Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
cache: pip

- name: Install dependencies
run: pip install -r requirements.txt ruff mypy pytest
run: pip install -r requirements-dev.txt

- name: Lint
run: ruff check clipsync/ tests/
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ jobs:
- name: Install Python deps
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt

- name: Build
run: python -m clipsync.build
Expand Down
98 changes: 63 additions & 35 deletions clipsync/clipboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

from __future__ import annotations

import importlib.util
import io
import logging
import os
Expand All @@ -36,6 +37,7 @@
import pyperclip
from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers import Observer
from watchdog.observers.api import BaseObserver

from . import config
from .crypto import decrypt, encrypt, is_encrypted
Expand All @@ -53,7 +55,7 @@
_STOP_SENTINEL = object()


def _try_start_xfixes_watcher() -> "queue.SimpleQueue[object] | None":
def _try_start_xfixes_watcher() -> queue.SimpleQueue[object] | None:
"""Start an X11 XFixes clipboard-owner watcher.

Returns a SimpleQueue that receives a True value each time the CLIPBOARD
Expand All @@ -69,8 +71,8 @@ def _try_start_xfixes_watcher() -> "queue.SimpleQueue[object] | None":
user has actually copied something.
"""
try:
from Xlib import display # type: ignore[import]
from Xlib.protocol import rq # type: ignore[import]
from Xlib import display
from Xlib.protocol import rq
except ImportError:
return None

Expand All @@ -89,29 +91,45 @@ def _try_start_xfixes_watcher() -> "queue.SimpleQueue[object] | None":

# Inline minimal XFixes protocol definitions -- python-xlib 0.15 doesn't
# ship an xfixes module, so we define only what we need here.
class _QueryVersion(rq.ReplyRequest): # type: ignore[misc]
class _QueryVersion(rq.ReplyRequest):
_request = rq.Struct(
rq.Card8("opcode"), rq.Opcode(0), rq.RequestLength(),
rq.Card32("client_major"), rq.Card32("client_minor"),
rq.Card8("opcode"),
rq.Opcode(0),
rq.RequestLength(),
rq.Card32("client_major"),
rq.Card32("client_minor"),
)
_reply = rq.Struct(
rq.ReplyCode(), rq.Pad(1), rq.Card16("sequence_number"),
rq.Card32("length"), rq.Card32("major_version"),
rq.Card32("minor_version"), rq.Pad(16),
rq.ReplyCode(),
rq.Pad(1),
rq.Card16("sequence_number"),
rq.Card32("length"),
rq.Card32("major_version"),
rq.Card32("minor_version"),
rq.Pad(16),
)

class _SelectSelectionInput(rq.Request): # type: ignore[misc]
class _SelectSelectionInput(rq.Request):
_request = rq.Struct(
rq.Card8("opcode"), rq.Opcode(2), rq.RequestLength(),
rq.Window("window"), rq.Card32("selection"), rq.Card32("event_mask"),
rq.Card8("opcode"),
rq.Opcode(2),
rq.RequestLength(),
rq.Window("window"),
rq.Card32("selection"),
rq.Card32("event_mask"),
)

class _SelectionNotify(rq.Event): # type: ignore[misc]
class _SelectionNotify(rq.Event):
_code = _first_event
_fields = rq.Struct(
rq.Card8("type"), rq.Card8("subtype"), rq.Card16("sequence_number"),
rq.Window("window"), rq.Card32("selection"), rq.Card32("owner"),
rq.Card32("selection_timestamp"), rq.Card32("timestamp"),
rq.Card8("type"),
rq.Card8("subtype"),
rq.Card16("sequence_number"),
rq.Window("window"),
rq.Card32("selection"),
rq.Card32("owner"),
rq.Card32("selection_timestamp"),
rq.Card32("timestamp"),
)

notify_q: queue.SimpleQueue[object] = queue.SimpleQueue()
Expand All @@ -122,15 +140,19 @@ def _watch() -> None:
d.display.extension_major_opcodes["XFIXES"] = _opcode
d.display.add_extension_event(_first_event, _SelectionNotify)
_QueryVersion(
display=d.display, opcode=_opcode,
client_major=5, client_minor=0,
display=d.display,
opcode=_opcode,
client_major=5,
client_minor=0,
)
d.sync()
root = d.screen().root
clipboard_atom = d.intern_atom("CLIPBOARD")
_SelectSelectionInput(
display=d.display, opcode=_opcode,
window=root, selection=clipboard_atom,
display=d.display,
opcode=_opcode,
window=root,
selection=clipboard_atom,
event_mask=1, # SelectionSetOwnerMask
)
d.flush()
Expand All @@ -147,7 +169,7 @@ def _watch() -> None:
return notify_q


def _try_start_xlib_clipboard_owner() -> "_XlibClipboardOwner | None":
def _try_start_xlib_clipboard_owner() -> _XlibClipboardOwner | None:
"""Try to create an in-process X11 clipboard owner using python-xlib.

Returns None on Wayland, missing python-xlib, or any startup error.
Expand All @@ -157,9 +179,7 @@ def _try_start_xlib_clipboard_owner() -> "_XlibClipboardOwner | None":
- Has no ownership-transition gap (we own the selection immediately)
- Responds to SelectionRequests in microseconds (single round trip)
"""
try:
from Xlib import display # type: ignore[import]
except ImportError:
if importlib.util.find_spec("Xlib") is None:
return None
try:
return _XlibClipboardOwner()
Expand All @@ -177,7 +197,7 @@ class _XlibClipboardOwner:
"""

def __init__(self) -> None:
from Xlib import X, Xatom, display # type: ignore[import]
from Xlib import X, Xatom, display

self._X = X
self._d = display.Display()
Expand All @@ -187,7 +207,7 @@ def __init__(self) -> None:
self._UTF8 = self._d.intern_atom("UTF8_STRING")
self._COMPOUND_TEXT = self._d.intern_atom("COMPOUND_TEXT")
self._TARGETS = self._d.intern_atom("TARGETS")
self._XA_ATOM = Xatom.ATOM # type for lists of atoms (= 4)
self._XA_ATOM = Xatom.ATOM # type for lists of atoms (= 4)
self._XA_STRING = Xatom.STRING # plain ASCII/Latin-1 string type (= 31)

self._content: str | None = None
Expand All @@ -196,9 +216,7 @@ def __init__(self) -> None:
# Self-pipe: writing a byte wakes the event loop.
self._pipe_r, self._pipe_w = os.pipe()

self._thread = threading.Thread(
target=self._event_loop, name="clipsync-xlib-owner", daemon=True
)
self._thread = threading.Thread(target=self._event_loop, name="clipsync-xlib-owner", daemon=True)
self._thread.start()

def set(self, text: str) -> None:
Expand Down Expand Up @@ -267,7 +285,7 @@ def _handle_event(self, event) -> None:
log.debug("xlib clipboard: lost CLIPBOARD ownership (SelectionClear)")

def _serve_request(self, req) -> None:
from Xlib.protocol.event import SelectionNotify # type: ignore[import]
from Xlib.protocol.event import SelectionNotify

X = self._X
with self._content_lock:
Expand Down Expand Up @@ -424,14 +442,14 @@ def __init__(self, settings: config.Settings) -> None:
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
self._observer: BaseObserver | None = None
self._last_synced: str | bytes | None = None
self._lock = threading.Lock()
self._last_read_error: str | None = None
self._last_write_error: str | None = None
self._last_decrypt_error: str | None = None
self._xfixes_queue: "queue.SimpleQueue[object] | None" = None
self._clipboard_owner: "_XlibClipboardOwner | None" = None
self._xfixes_queue: queue.SimpleQueue[object] | None = None
self._clipboard_owner: _XlibClipboardOwner | None = None
self._history = ClipboardHistory(settings)

@property
Expand All @@ -452,8 +470,10 @@ def start(self) -> None:
_no_xlib = os.environ.get("CLIPSYNC_NO_XLIB")
self._xfixes_queue = _try_start_xfixes_watcher() if not _no_xfixes else None
if self._xfixes_queue is None:
log.debug("XFixes unavailable%s; falling back to clipboard polling",
" (CLIPSYNC_NO_XFIXES set)" if _no_xfixes else "")
log.debug(
"XFixes unavailable%s; falling back to clipboard polling",
" (CLIPSYNC_NO_XFIXES set)" if _no_xfixes else "",
)
if not _no_xlib:
self._clipboard_owner = _try_start_xlib_clipboard_owner()
if self._clipboard_owner is None:
Expand Down Expand Up @@ -491,6 +511,14 @@ def stop(self) -> None:
self._poll_thread.join(timeout=3)
log.info("Clipboard sync stopped")

def clear_history(self) -> None:
"""Drop all stored clipboard history entries (called from UI events).

Exposed as a public method so callers don't need to reach into
the private _history attribute.
"""
self._history.clear()

def _passphrase(self) -> str:
val = self._settings.get("encryption_passphrase") or ""
return val if isinstance(val, str) else ""
Expand Down
33 changes: 28 additions & 5 deletions clipsync/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,14 +114,35 @@ def _load(self) -> None:
with self._path.open("r", encoding="utf-8") as fh:
loaded = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
# Do NOT overwrite a corrupted/unreadable file with defaults.
# The user may still be able to recover it manually; clobbering
# it here turns a transient parse error into permanent data
# loss. Stay on defaults in memory and let the next successful
# set() re-persist.
logging.warning("Failed to read settings, using defaults: %s", exc)
loaded = {}
return
if not isinstance(loaded, dict):
logging.warning("Settings file did not contain a JSON object; using defaults")
return
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._persist_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
# churn and could race with a concurrent writer (e.g. a UI
# subprocess that just wrote a new value).
loaded_keys = set(loaded.keys())
needs_persist = not loaded.get("api_key") or any(k not in loaded_keys for k in DEFAULT_SETTINGS)
if needs_persist:
self._persist_locked()
else:
try:
self._mtime_ns = self._path.stat().st_mtime_ns
except OSError:
pass

def _persist_locked(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -178,6 +199,9 @@ def reload(self) -> None:
except (OSError, json.JSONDecodeError) as exc:
logging.warning("Failed to reload settings: %s", exc)
return
if not isinstance(loaded, dict):
logging.warning("Settings file did not contain a JSON object; keeping in-memory state")
return
merged = dict(DEFAULT_SETTINGS)
merged.update({k: v for k, v in loaded.items() if k in DEFAULT_SETTINGS})
self._data = merged
Expand Down Expand Up @@ -213,9 +237,8 @@ def configure_logging() -> None:
datefmt="%Y-%m-%d %H:%M:%S",
)
from logging.handlers import RotatingFileHandler
file_handler = RotatingFileHandler(
LOG_FILE, encoding="utf-8", maxBytes=10 * 1024 * 1024, backupCount=3
)

file_handler = RotatingFileHandler(LOG_FILE, encoding="utf-8", maxBytes=10 * 1024 * 1024, backupCount=3)
file_handler.setFormatter(fmt)
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(fmt)
Expand Down
6 changes: 4 additions & 2 deletions clipsync/file_transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
from __future__ import annotations

import logging
import os
import shutil
import time
from collections.abc import Callable
from pathlib import Path

from watchdog.events import FileSystemEvent, FileSystemEventHandler
from watchdog.observers import Observer
from watchdog.observers.api import BaseObserver

from . import config
from .debug import _safe_hostname
Expand All @@ -38,7 +40,7 @@ def __init__(
) -> None:
self._settings = settings
self._on_received = on_received
self._observer: Observer | None = None
self._observer: BaseObserver | None = None

@property
def files_dir(self) -> Path:
Expand Down Expand Up @@ -105,7 +107,7 @@ def _handle(self, path: Path) -> None:

def on_created(self, event: FileSystemEvent) -> None:
if not event.is_directory:
self._handle(Path(event.src_path))
self._handle(Path(os.fsdecode(event.src_path)))

def on_moved(self, event: FileSystemEvent) -> None:
# Syncthing uses atomic rename: .syncthing.*.tmp → final name.
Expand Down
5 changes: 1 addition & 4 deletions clipsync/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,11 @@
import threading
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from typing import Any

from . import config
from .crypto import decrypt, encrypt, is_encrypted

if TYPE_CHECKING:
pass

log = logging.getLogger(__name__)


Expand Down
Loading
Loading