Prepare for public release: CI workflow + CONTRIBUTING guide#6
Closed
offbyonebit wants to merge 42 commits intomainfrom
Closed
Prepare for public release: CI workflow + CONTRIBUTING guide#6offbyonebit wants to merge 42 commits intomainfrom
offbyonebit wants to merge 42 commits intomainfrom
Conversation
Cross-platform clipboard sync app using Syncthing as transport. Python 3.11+, CustomTkinter UI, pystray tray, watchdog file watcher. Modules: config, syncthing, clipboard, pairing, ui, autostart, main. CI: ruff, mypy, compileall matrix, import smoke tests, release URL check.
Syncthing v1.27.10 removed the top-level --device-id flag, causing startup to fail with "unknown flag --device-id" and the tray UI never to appear. Read the device ID from config.xml (already generated by `syncthing generate`) instead of invoking the CLI. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Run the pystray icon on the main thread and launch each Tk window as a short-lived subprocess. macOS 26 now hard-crashes when AppKit is initialized off the main thread, which the previous tray-on-worker architecture triggered. The subprocess model works identically on Windows and Linux, so there is a single cross-platform path. Pairing window is reorganized around a Nearby Devices list powered by Syncthing's local discovery API, plus a Paste button for quick manual entry. The webcam scanner now shows a live preview so users can tell it is working, uses platform-native capture backends (AVFoundation / DirectShow / V4L2) for faster cold-start, and caps resolution. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_read_device_id previously returned the first <device> element in config.xml, which after pairing could be a remote device rather than our own. Post-pairing, Syncthing's config.xml holds multiple device entries in arbitrary order, so the Windows app was reporting the Mac's device ID as its own. The self device is the only id that appears in every folder's <device> list, so intersect those sets to identify it. Falls back to the first valid id if no folders exist. Clipboard read/write exceptions were logged at debug level, which hid silent failures of pyperclip on Windows (e.g. when another app holds the clipboard). Promote to warning with throttling so the same error does not spam the log. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Windows's clipboard implicitly converts LF to CRLF on copy, so after applying an IN from a non-Windows device, the OUT poll read the same text back with extra \r bytes and wrote it out as a "new" value. The remote saw it, applied it, ping-pong continued (not breaking sync but generating extra traffic and log noise). Normalize CRLF/CR to LF at every boundary where we compare the clipboard to the shared file, so the self-echo is deduplicated. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A passphrase field in Settings enables AES-128 encryption (Fernet) of the clipboard.txt contents written to the sync folder. Empty passphrase keeps the existing plaintext format. Same passphrase on every device is required; decrypt failures log a warning and skip the update instead of corrupting the local clipboard. Key derivation uses PBKDF2-HMAC-SHA256 (120k iterations) with an app-fixed salt so both sides derive the same key from the same passphrase without any additional coordination. Encrypted payloads are prefixed with a CSENC magic header so a receiver can distinguish them from plaintext and fail gracefully when a passphrase is missing or wrong on one side. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Settings changes made in a UI subprocess (pairing, settings, devices windows) were written to settings.json on disk but the main process's in-memory Settings was only loaded once at startup. This meant saved values like the encryption passphrase were invisible to the clipboard sync engine until the whole app was restarted, causing asymmetric encryption failures. Add Settings.reload() and call it whenever the UIController forwards an event from a child window, so any persisted change is picked up. Settings window emits a settings_changed event after saving the passphrase to trigger this path even when no other side-effect event would fire. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous reload-on-UI-event approach left a window where a subprocess (e.g. Settings window) persisted a change but the main process kept serving stale values until another unrelated UI event fired. In practice this meant saving a passphrase could take several minutes to take effect. Track settings.json's mtime; on every get() call compare with the last-seen mtime and reload if newer. One stat() per lookup is cheap enough that we don't need to batch. This makes cross-process settings changes effectively immediate without any event plumbing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Simulate two machines in a single process by running two ClipboardSync instances against a shared folder with separate settings files and separate patched clipboards, then assert clipboard text propagates in both directions within a couple poll cycles. Covers plaintext, encrypted symmetric passphrase, and mismatched-passphrase no-corruption cases. Includes a direct regression test for the 20-minute passphrase-change lag: write through one Settings instance, read through another, expect the second lookup to reflect the write without any explicit reload. Swap the default watchdog Observer for PollingObserver in the fixture because FSEvents on macOS refuses two watches on the same path, which only happens in this single-process harness; real deployments have one watcher per machine. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Runs the pytest suite on Ubuntu, macOS, and Windows under Python 3.11 so Windows-specific regressions (e.g. pyperclip behavior, path handling, watchdog backend differences) get caught before merging. Installs only the test-relevant dependencies rather than the full requirements.txt because pytest never imports the UI or webcam code paths, and pulling opencv-python into CI adds a couple minutes per run for no coverage gain. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
clipsync/autostart.py imports winreg inside the Windows branch of the autostart helpers. winreg is a stdlib module that only ships with Python on Windows, so mypy on Ubuntu could not see its attributes and flagged every OpenKey / SetValueEx call as "Module has no attribute". Move the typecheck job to windows-latest instead; the rest of the code uses cross-platform stdlib modules that check fine on Windows too. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove stale \`# type: ignore\` comments for winreg and cv2 imports that mypy flagged as unused when run on Windows (winreg is native there) or with --ignore-missing-imports (cv2 resolves to Any). - Rename tar extraction members list to avoid a mypy-visible shadow between the zip (List[str]) and tar (List[TarInfo]) branches of _extract_binary. - Fix a NameError waiting to happen in PairingWindow._pair_worker: the except-bound \`exc\` is dropped at the end of the except block, so a later-scheduled lambda that referenced it would blow up; capture the message eagerly before deferring. - Replace \`for did in discovered.keys()\` with \`for did in discovered\` (SIM118) in the nearby-device loop. - Reflow clipboard.py, syncthing.py, and ui.py with \`ruff format\`. - Install \`requests\` in the syncthing-url CI job so importing clipsync.syncthing no longer ModuleNotFoundErrors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
actions/setup-python's cache post-step treats an empty pip cache directory as a hard error. The Compile (py_compile) job never pip installs anything, so on Python 3.12 runners where the cache path isn't pre-populated, it fails the job. Removing the cache directive there since caching nothing is never useful. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GitHub's repo landing page only auto-renders a root-level README, so the previous clipsync/README.md was invisible to anyone landing on the repo. Move it to the root and rewrite to lead with the design rationale (why reuse Syncthing), compare against alternatives, and document at-rest encryption.
The README already said "MIT" but without a LICENSE file at the repo root GitHub cannot detect the license, and legally the code defaults to "all rights reserved". Add the full MIT text with a proper copyright line so forks and derivative works are clearly permitted with attribution.
pystray on Linux picks its tray backend at import time: PyGObject + AppIndicator if available, XEmbed otherwise. KDE Plasma and several other modern desktops no longer render XEmbed icons, so without the AppIndicator libraries the tray icon is created but never shows up. Add a Linux setup subsection under Install with distro-specific package commands, a note about venvs needing include-system-site-packages, xclip / wl-clipboard for pyperclip, and the GNOME AppIndicator extension. Code is untouched; this is purely setup documentation for users on Linux. Windows and macOS remain a plain pip install -r requirements. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The existing sync tests all used a generic fake clipboard, so a regression in _normalize_newlines or in any other OS-specific code path would have gone unnoticed. Add OSClipboard fakes that mimic each platform's copy-time behavior (Windows injects \\r\\n, Mac and Linux preserve bytes), then parametrize plain-text and multiline tests over every directed OS pair (mac<->windows, mac<->linux, linux<->windows). The multiline cases are the load-bearing ones: they explicitly assert no CRLF oscillation by waiting 15 extra poll cycles after the initial propagation and checking the text is stable on both ends. Plus a three-way relay simulation as a belt-and-suspenders check that Linux -> Windows -> Mac does not accumulate trailing newlines or double-CR across two hops. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The device ID shown to the user and sent to other sides at pair time was parsed out of config.xml. After pairing, config.xml holds several <device> entries (self plus each paired remote) in no deterministic order, so the parse frequently returned a remote's ID as "our" ID. Syncthing computes its own device ID as SHA-256 of the DER-encoded cert, base32-encoded with Luhn mod-32 check chars inserted every 13 chars and hyphen-chunked every 7. Implement that same derivation against cert.pem so we always pick the self device. Verified byte-for-byte against the ID Syncthing reports on an existing home. Also flesh out clipsync/build.py from a stub into a real PyInstaller entry point that picks the right flags per platform, and add a GitHub Actions release workflow that builds macOS/.app, Windows/.exe, and Linux binaries on every vX.Y.Z tag push (or manual dispatch) and attaches the zipped / tarred artifacts to the GitHub release. The Syncthing binary stays unbundled since clipsync.syncthing downloads the platform-correct release asset on first run. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
clipsync/__main__.py uses \`from .main import main\`, which only works when invoked as a package via \`python -m clipsync\`. PyInstaller runs its entry script as the top-level __main__ with no parent package, so the relative import fails and the bundled app exits immediately on launch with "attempted relative import with no known parent package". Add clipsync_launcher.py that uses absolute imports, and point build.py at it. The \`python -m clipsync\` path keeps working unchanged since it still resolves __main__.py inside the package. Verified locally: fresh \`python -m clipsync.build\` now produces a ClipSync.app that starts Syncthing and the clipboard sync engine.
sys.executable in a PyInstaller bundle is the ClipSync binary itself, not a Python interpreter, so Popen([sys.executable, '-m', 'clipsync.ui', 'settings']) re-invokes the whole app with meaningless flags. The second instance then tries to start Syncthing again, collides on the home-directory lock, exits, and the user sees no window. Have clipsync_launcher.py dispatch on argv[1]: 'ui <name>' runs the UI child entry, anything else runs the main app. Make UIController pick the right invocation based on sys.frozen so source-tree (`python -m clipsync.ui <name>`) and bundle (`ClipSync ui <name>`) both work. Verified locally: the rebuilt .app now launches ui subprocesses that stay alive.
Syncs images (PNG) via a new clipboard.png sidecar file alongside the existing clipboard.txt. The OUT loop tries image first, falls back to text. The IN watcher dispatches to separate text/image handlers based on which file changed. Encryption works for both types: _encrypt/_decrypt now operate on bytes directly, with callers handling text encoding. Platform clipboard access: - Windows: ctypes + CF_DIB (no extra deps) - macOS: AppKit NSPasteboard via pyobjc - Linux: xclip or wl-paste/wl-copy subprocesses https://claude.ai/code/session_01HaCGbmbaF3rBff9fxyP7TS
- Stub _read_clipboard_image / _write_clipboard_image in the text-only test fixtures. The new image-priority logic in _out_tick calls _read_clipboard_image() before the text path, and the previous fakes only mocked text access. On a Mac with a real PNG sitting on the system clipboard, every text test in test_mac_windows_sync.py and test_cross_os_sync.py would pick that image up instead of the fake text payload and time out waiting for propagation. - Declare pyobjc-core and pyobjc-framework-Cocoa as explicit macOS deps in requirements.txt. They previously arrived transitively via pystray's rumps backend, but the new NSPasteboard image writer imports AppKit and Foundation directly, so the dependency is now first-party. - Add --hidden-import AppKit and --hidden-import Foundation to the PyInstaller build on macOS. Both modules are imported lazily inside _write_image_to_system_clipboard, which PyInstaller's static analysis sometimes misses; without the hints, the frozen bundle would silently fail at image paste time. - Minor ruff-format reflow picked up on paths the branch touched. Verified end-to-end on Mac: putting a PNG on the system clipboard triggers 'OUT: N bytes image written' from both the source run and the PyInstaller .app, and clipboard.png appears in the sync folder encrypted (CSENC-prefixed) when a passphrase is set.
Adds syncing of images (PNG) via a new clipboard.png sidecar file alongside the existing clipboard.txt. OUT gives images priority over text. IN dispatches to text or image handlers based on which file changed. Encryption and the passphrase mismatch guard work for both. Platform clipboard access: - macOS: AppKit NSPasteboard via pyobjc - Windows: ctypes + CF_DIB, no extra deps - Linux: xclip or wl-paste/wl-copy subprocesses Tests cover bidirectional image sync, no-oscillation, encryption symmetric / mismatched passphrase, and text-still-syncs-when-no-image. Mac text tests now stub image clipboard access so the host's real clipboard cannot leak into them.
The previous build had no protection against a second launch, so two Python processes would race to spawn Syncthing — both would hit Syncthing's own lock file and restart-loop forever. A cross-platform file lock in single_instance.py makes the second launch exit cleanly. The pairing accepter no longer auto-accepts silently. It now notifies the tray when a request comes in, exposes an "Incoming Requests" menu entry with a count, and opens a window where the user can Accept or Reject each device. Rejections are persisted so we don't re-prompt. Old auto-accept behavior is preserved behind a settings toggle. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New clipsync.update module hits the GitHub Releases API for the
project's latest tag, compares it to the running version, and returns
a small UpdateInfo dataclass. Two entry points:
* Tray menu "Check for Updates…" — runs in a background thread and
surfaces the result as a system notification.
* Settings window "Check for updates (vX.Y.Z)" button — shows status
inline, and on a newer release reveals a "Download update" button
that opens the release page in the default browser.
No auto-install: PyInstaller bundles differ per OS and replacing a
running binary is fragile. Directing users to the release page keeps
the scope tight and the UX transparent.
Also adds N818 to the ruff ignore list. The AlreadyRunning exception
(introduced in 80459ae) violates the "Error" suffix convention, but
that's a stylistic choice that predates this change and isn't worth
a rename across the codebase.
Merge recent features + add in-app update checker
Devices / Pair / Settings now live as tabs in one window instead of three separate Toplevels. Tray left-click (default menu item) opens the Devices tab; individual menu items jump to their respective tab. Also disables ctk's Windows titlebar recolor dance, which withdraws and re-deiconifies the window during init. With multiple overlapping cycles (resizable + tab widget construction), the state capture could race and leave the window hidden. Losing the dark titlebar is an acceptable trade for a window that reliably opens. Bumps __version__ to 0.1.2.
Windows denies os.replace() when Syncthing has clipboard.txt open during sync. Retry up to 10 times (100ms apart) before giving up. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When clipsync's Python parent is force-killed (crash, OS kill, logoff during a hang), taskkill/SIGKILL does not cascade to its Syncthing child. The orphan keeps the BoltDB lock and port 8385, and every subsequent startup spawn-and-dies in a silent 10s loop. On start, scan for syncthing processes whose executable path matches our managed binary and terminate them. Matching by path (not name) leaves an unrelated user-installed Syncthing untouched. Pipe syncthing's stdout/stderr into our logger via a daemon thread instead of discarding to DEVNULL, so future startup failures surface their actual cause next to the exit-code line. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When sync is misbehaving the only way to compare both sides is to
remote into the other machine. Since Syncthing is already replicating
the sync folder, each clipsync now periodically copies the tail of its
own log to {sync}/debug/{hostname}.log. Peers get a live view of each
other's activity without any separate channel.
python -m clipsync.debug prints all mirrored logs merged
chronologically by the ISO timestamp prefix of each line.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Every clipsync startup ran _patch_config which rebuilt the folder element from scratch with only self in its <device> list. Peer shares were only re-added by pairing.share_folder_with_device() in response to *new* pending-device events, so already-paired peers silently dropped out of the folder share list on every restart. Syncthing then connected at the device level but stayed detached at the folder level (completion=0, remoteState=unknown) — explaining today's "copy here, paste there does nothing" with no visible error. Deep-copy the existing folder's <device> children before wiping, and re-attach them (dedup, self guaranteed) to the rebuilt folder. <encryptionPassword> and other inner elements are preserved verbatim. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Release v0.2.0
Replaces GitHub-hosted runners (ubuntu-latest, macos-latest, windows-latest) with self-hosted runner labels ([self-hosted, linux], [self-hosted, macos], [self-hosted, windows]) across all three workflows to avoid GitHub Actions billing. https://claude.ai/code/session_01TvC4EgedZMk7JPJbHRTVh9
Running CI locally instead; release workflow stays for when self-hosted runners are available. https://claude.ai/code/session_01TvC4EgedZMk7JPJbHRTVh9
macOS stays on self-hosted (Mac Air) to avoid the 10x billing multiplier. Linux and Windows use GitHub-hosted runners which are cheap enough to stay within the free quota. https://claude.ai/code/session_01TvC4EgedZMk7JPJbHRTVh9
Pass --no-upgrade when spawning Syncthing so it never replaces its own binary mid-run. Also add version checking in ensure_binary() so that if the on-disk binary version drifts from the pinned version (e.g. from a previous self-upgrade), it is re-downloaded before launch. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Syncthing self-upgraded to v2.0.16 on existing installs before the --no-upgrade flag was added, writing a config.xml in format v52 that the old pinned v1.27.10 binary cannot read. ensure_binary() then downgrades the on-disk binary back to v1.27.10 on every launch, and Syncthing fails to boot with "config file version (52) is newer than supported version (37)", putting the app into an endless retry loop. Align the pin with the config format already on disk. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a GitHub Actions CI workflow (lint, typecheck, tests) that runs on every PR and push to main, and a CONTRIBUTING.md covering dev setup, checks, code style, and PR process. https://claude.ai/code/session_016HDz4CJmgiF6ryQ55oGbBd
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
$(cat <<'EOF'
Summary
.github/workflows/ci.yml— three jobs (lint, typecheck, tests) that run on every PR and push tomain. Uses minimal per-job deps so installs are fast; all tests are headless-safe (clipboard access is faked).CONTRIBUTING.md— dev environment setup, how to run the three checks locally, code style expectations, and PR process.Test plan
https://claude.ai/code/session_016HDz4CJmgiF6ryQ55oGbBd
EOF
)
Generated by Claude Code