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
17 changes: 15 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ on:

jobs:
lint-and-test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4

Expand All @@ -26,6 +30,15 @@ jobs:
- name: Install dependencies
run: pip install -r requirements-dev.txt

- name: Start xvfb (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y xvfb
export DISPLAY=:99
Xvfb :99 -screen 0 1024x768x24 &
echo "DISPLAY=:99" >> "$GITHUB_ENV"

- name: Lint
run: ruff check clipsync/ tests/

Expand All @@ -36,4 +49,4 @@ jobs:
run: mypy clipsync/

- name: Test
run: pytest tests/ -q
run: pytest tests/ -q -m "not integration"
29 changes: 10 additions & 19 deletions clipsync/autostart.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,26 +54,17 @@ def _macos_set(enabled: bool) -> None:
if path.exists():
path.unlink()
return
argv = _launch_command()
args_xml = "\n".join(f" <string>{a}</string>" for a in argv)
plist = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
'"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
'<plist version="1.0">\n'
"<dict>\n"
f" <key>Label</key><string>{_BUNDLE_ID}</string>\n"
" <key>ProgramArguments</key>\n"
" <array>\n"
f"{args_xml}\n"
" </array>\n"
" <key>RunAtLoad</key><true/>\n"
" <key>KeepAlive</key><false/>\n"
"</dict>\n"
"</plist>\n"
)
import plistlib

plist = {
"Label": _BUNDLE_ID,
"ProgramArguments": _launch_command(),
"RunAtLoad": True,
"KeepAlive": False,
}
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(plist, encoding="utf-8")
with path.open("wb") as fh:
plistlib.dump(plist, fh)


def _linux_desktop_path() -> Path:
Expand Down
110 changes: 61 additions & 49 deletions clipsync/clipboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,8 @@ def _read_image_from_system_clipboard() -> bytes | None:
# Linux: check TARGETS first so we never send an image/png SelectionRequest
# to the clipboard owner when only text is present. Without this guard,
# xclip would request image data even when the clipboard holds text.
# Try each available command in turn; xclip failing (e.g. on a text-only
# Wayland clipboard) must not prevent wl-paste from running.
for targets_cmd in (
["xclip", "-selection", "clipboard", "-t", "TARGETS", "-o"],
["wl-paste", "--list-types"],
Expand All @@ -392,9 +394,10 @@ def _read_image_from_system_clipboard() -> bytes | None:
res = subprocess.run(targets_cmd, capture_output=True, timeout=1)
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
continue
if res.returncode != 0 or b"image/png" not in res.stdout:
return None
break
if res.returncode == 0 and b"image/png" in res.stdout:
break
else:
return None
# Some xclip versions return text content with exit 0 even when asked for
# image/png and no image is on the clipboard. Guard with a PNG magic-byte
# check so we never mistake text bytes for image data.
Expand All @@ -415,8 +418,8 @@ def _write_image_to_system_clipboard(png_bytes: bytes) -> bool:
"""Write PNG bytes to the system clipboard. Returns True on success."""
if sys.platform == "darwin":
try:
from AppKit import NSImage, NSPasteboard # type: ignore[import]
from Foundation import NSData # type: ignore[import]
from AppKit import NSImage, NSPasteboard
from Foundation import NSData

ns_data = NSData.dataWithBytes_length_(png_bytes, len(png_bytes))
ns_image = NSImage.alloc().initWithData_(ns_data)
Expand Down Expand Up @@ -613,6 +616,30 @@ def _refuse_if_unreadable_ciphertext(self, path: Path) -> None:
if decrypt(data, passphrase) is None:
raise EncryptedPayloadError(path)

def _atomic_write(self, path: Path, payload: bytes) -> None:
"""Write *payload* to *path* atomically and clean up any temp file.

Uses a uniquely-named temp file so concurrent writers cannot collide,
and unlinks the temp file on failure so partial writes do not litter
the sync folder.
"""
import secrets

tmp = path.with_name(f"{path.name}.{os.getpid()}.{secrets.token_hex(4)}.tmp")
try:
tmp.write_bytes(payload)
for attempt in range(10):
try:
tmp.replace(path)
config.set_file_permissions(path)
return
except PermissionError:
if attempt == 9:
raise
time.sleep(0.1)
finally:
tmp.unlink(missing_ok=True)

def _write_file(self, text: str) -> None:
"""Atomic write of the shared file, encrypting if a passphrase is set."""
path = self.clipboard_file
Expand All @@ -621,17 +648,7 @@ def _write_file(self, text: str) -> None:
passphrase = self._passphrase()
encoded = text.encode("utf-8")
payload = encrypt(encoded, passphrase) if passphrase else encoded
tmp = path.with_name(path.name + ".tmp")
tmp.write_bytes(payload)
for attempt in range(10):
try:
tmp.replace(path)
config.set_file_permissions(path)
return
except PermissionError:
if attempt == 9:
raise
time.sleep(0.1)
self._atomic_write(path, payload)

def _read_image_file(self) -> bytes | None:
"""Return PNG bytes from the shared image file, decrypting if needed."""
Expand Down Expand Up @@ -674,17 +691,7 @@ def _write_image_file(self, png_bytes: bytes) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
passphrase = self._passphrase()
payload = encrypt(png_bytes, passphrase) if passphrase else png_bytes
tmp = path.with_name(path.name + ".tmp")
tmp.write_bytes(payload)
for attempt in range(10):
try:
tmp.replace(path)
config.set_file_permissions(path)
return
except PermissionError:
if attempt == 9:
raise
time.sleep(0.1)
self._atomic_write(path, payload)

def _seed_from_file(self) -> None:
"""Prime _last_synced from disk so we don't re-emit stale content on startup.
Expand Down Expand Up @@ -854,10 +861,16 @@ def _out_loop(self) -> None:
_last_heartbeat = now
with self._lock:
last = self._last_synced
if isinstance(last, bytes):
desc = f"<image {len(last)} bytes>"
elif isinstance(last, str):
desc = f"<text {len(last)} chars>"
else:
desc = "<none>"
log.debug(
"HEARTBEAT (host=%s): last_synced=%s, paused=%s",
_HOSTNAME,
_truncate_for_log(last),
desc,
self._is_paused(),
)

Expand All @@ -868,25 +881,20 @@ def _out_tick(self) -> None:
with self._lock:
if image == self._last_synced:
return
previous_last_synced = self._last_synced
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
reason = "Refusing to overwrite encrypted clipboard image file (cannot decrypt)"
if reason != self._last_decrypt_error:
log.warning("OUT [%s]: %s", _HOSTNAME, reason)
self._last_decrypt_error = reason
return
except OSError:
# 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._last_synced = previous_last_synced
log.exception("OUT [%s]: Failed to write image file", _HOSTNAME)
return
with self._lock:
self._last_synced = image
log.info("OUT [%s]: %d bytes image written", _HOSTNAME, len(image))
return

current = self._read_clipboard()
Expand All @@ -895,23 +903,21 @@ def _out_tick(self) -> None:
with self._lock:
if current == self._last_synced:
return
previous_last_synced = self._last_synced
self._last_synced = current
try:
self._write_file(current)
log.info("OUT [%s]: %d chars written", _HOSTNAME, len(current))
self._history.add_entry(current, "local")
except EncryptedPayloadError:
with self._lock:
self._last_synced = previous_last_synced
reason = "Refusing to overwrite encrypted clipboard file (cannot decrypt)"
if reason != self._last_decrypt_error:
log.warning("OUT [%s]: %s", _HOSTNAME, reason)
self._last_decrypt_error = reason
return
except OSError:
with self._lock:
self._last_synced = previous_last_synced
log.exception("OUT [%s]: Failed to write clipboard file", _HOSTNAME)
return
with self._lock:
self._last_synced = current
log.info("OUT [%s]: %d chars written", _HOSTNAME, len(current))
self._history.add_entry(current, "local")

def _in_loop(self) -> None:
"""Drain _in_queue and apply remote file changes to the local clipboard.
Expand Down Expand Up @@ -1032,19 +1038,25 @@ def _dispatch(self, path: str) -> None:
# is never held by clipboard I/O (avoids pool exhaustion on Windows).
self._sync._in_queue.put(path)

def _path_str(self, path: str | bytes) -> str:
"""Decode watchdog paths safely; surrogateescape preserves non-UTF-8 bytes."""
if isinstance(path, str):
return path
return path.decode("utf-8", errors="surrogateescape")

def on_modified(self, event: FileSystemEvent) -> None:
if event.is_directory:
return
self._dispatch(event.src_path if isinstance(event.src_path, str) else event.src_path.decode())
self._dispatch(self._path_str(event.src_path))

def on_created(self, event: FileSystemEvent) -> None:
if event.is_directory:
return
self._dispatch(event.src_path if isinstance(event.src_path, str) else event.src_path.decode())
self._dispatch(self._path_str(event.src_path))

def on_moved(self, event: FileSystemEvent) -> None:
if event.is_directory:
return
dest = getattr(event, "dest_path", "")
if dest:
self._dispatch(dest)
self._dispatch(self._path_str(dest))
58 changes: 53 additions & 5 deletions clipsync/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,13 +169,20 @@ def _load(self) -> None:
if not merged.get("api_key"):
merged["api_key"] = uuid.uuid4().hex
self._data = merged
# Migrate any plaintext passphrase into secure storage.
self._maybe_migrate_passphrase()
# 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).
# key), has an empty api_key that we just generated, or still holds a
# plaintext passphrase that was just migrated. 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)
needs_persist = (
not loaded.get("api_key")
or any(k not in loaded_keys for k in DEFAULT_SETTINGS)
or loaded.get("encryption_passphrase", "") != ""
)
if needs_persist:
self._persist_locked()
else:
Expand All @@ -184,6 +191,25 @@ def _load(self) -> None:
except OSError:
pass

def _maybe_migrate_passphrase(self) -> None:
"""Move plaintext passphrases from settings.json into secure storage."""
plaintext = self._data.get("encryption_passphrase", "")
if not plaintext or not isinstance(plaintext, str):
return
try:
from .secure_settings import migrate_plaintext_passphrase

migrate_plaintext_passphrase(self, self._secure_namespace())
except Exception:
logging.warning("Could not migrate plaintext passphrase", exc_info=True)

def _secure_namespace(self) -> str:
"""Stable namespace isolating secure storage per settings file."""
try:
return str(self._path.resolve())
except OSError:
return str(self._path)

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")
Expand Down Expand Up @@ -216,10 +242,32 @@ 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":
in_memory = self._data.get(key, default)
if in_memory:
return in_memory
try:
from .secure_settings import get_passphrase

stored = get_passphrase(self._secure_namespace())
if stored is not None:
return stored
except Exception:
logging.warning("Could not read passphrase from secure storage", exc_info=True)
return self._data.get(key, default)

def set(self, key: str, value: Any) -> None:
with self._lock:
if key == "encryption_passphrase":
try:
from .secure_settings import set_passphrase

set_passphrase(value if value else None, self._secure_namespace())
except Exception:
logging.warning("Could not write passphrase to secure storage", exc_info=True)
# Keep the plaintext field empty; the passphrase lives in the
# OS keychain or the encrypted fallback file.
value = ""
self._data[key] = value
self._persist_locked()

Expand Down
Loading
Loading