Skip to content

Prepare for public release: CI workflow + CONTRIBUTING guide#6

Closed
offbyonebit wants to merge 42 commits intomainfrom
claude/prepare-public-release-1pHMQ
Closed

Prepare for public release: CI workflow + CONTRIBUTING guide#6
offbyonebit wants to merge 42 commits intomainfrom
claude/prepare-public-release-1pHMQ

Conversation

@offbyonebit
Copy link
Copy Markdown
Owner

$(cat <<'EOF'

Summary

  • Adds .github/workflows/ci.yml — three jobs (lint, typecheck, tests) that run on every PR and push to main. Uses minimal per-job deps so installs are fast; all tests are headless-safe (clipboard access is faked).
  • Adds CONTRIBUTING.md — dev environment setup, how to run the three checks locally, code style expectations, and PR process.

Test plan

  • CI workflow triggers on this PR and all three jobs pass (lint, typecheck, tests)
  • CONTRIBUTING.md renders correctly on GitHub

https://claude.ai/code/session_016HDz4CJmgiF6ryQ55oGbBd
EOF
)


Generated by Claude Code

offbyonebit and others added 30 commits April 17, 2026 13:19
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>
offbyonebit and others added 12 commits April 21, 2026 09:52
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>
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants